Skip to content

Conversation

@fedemkr
Copy link
Member

@fedemkr fedemkr commented Nov 28, 2025

🎟️ Tracking

PM-28855

📔 Objective

Add cipher changes subscription to update credential identities store on iOS extensions.
This differs from the current approach where all ciphers/credentials get updated to be done on individual cipher CUD helping memory efficiency.
As you can see the new CipherChangePublisher publishes only when "one" cipher operation was done, but it doesn't when there's a batch operation like replacing all ciphers to avoid memory problems on the extensions.

Important

This is a solution for iOS 17+ versions as they have incremental updates on the credential identities store. I didn't add the fallback as it'd imply replacing all identities from all ciphers which means loading and decrypting all (or most) ciphers which may crash on large vaults or have the extension use more memory leaving less for other operations like searching before crashing. The user can always force a sync on the main app to update the OS store and have autofill working for that specific credential again.

⏰ Reminders before review

  • Contributor guidelines followed
  • All formatters and local linters executed and passed
  • Written new unit and / or integration tests where applicable
  • Protected functional changes with optionality (feature flags)
  • Used internationalization (i18n) for all UI strings
  • CI builds passed
  • Communicated to DevOps any deployment requirements
  • Updated any necessary documentation (Confluence, contributing docs) or informed the documentation team

🦮 Reviewer guidelines

  • 👍 (:+1:) or similar for great changes
  • 📝 (:memo:) or ℹ️ (:information_source:) for notes or general info
  • ❓ (:question:) for questions
  • 🤔 (:thinking:) or 💭 (:thought_balloon:) for more open inquiry that's not quite a confirmed issue and could potentially benefit from discussion
  • 🎨 (:art:) for suggestions / improvements
  • ❌ (:x:) or ⚠️ (:warning:) for more significant problems or concerns needing attention
  • 🌱 (:seedling:) or ♻️ (:recycle:) for future improvements or indications of technical debt
  • ⛏ (:pick:) for minor or nitpick changes

…tities store when cipher get CUD on iOS extensions.
@fedemkr fedemkr marked this pull request as ready for review November 28, 2025 20:23
@fedemkr fedemkr requested review from a team and matt-livefront as code owners November 28, 2025 20:24
@claude
Copy link

claude bot commented Nov 28, 2025

Claude finished @fedemkr's task —— View job


Code Review Complete

Summary

This PR introduces an efficient mechanism to update the OS credential identities store on iOS 17+ extensions by subscribing to individual cipher changes rather than batch-replacing all credentials. The implementation is architecturally sound with strong test coverage (94.9%).

Key Changes:

  • New CipherChangePublisher for observing individual cipher operations
  • Extension-only subscription in AutofillCredentialService
  • Incremental store updates via upsertCredentialsInStore/removeCredentialsInStore
  • iOS 17+ only (leverages supportsIncrementalUpdates)

Findings

Finding 1: Missing error handling for cipherChangesPublisher() failures

Critical Issue - See inline comment

In AutofillCredentialService.swift:234, the subscribeToCipherChanges() method catches and logs errors from cipherChangesPublisher(), but then the subscription silently dies.

Problem: If cipherChangesPublisher() throws during initialization (e.g., state service fails to get active account), the error is logged but the subscription task completes. The service continues running without any cipher change subscription, and hasCipherChangesSubscription will return false (task completed).

Impact: Credential identities won't update in extensions after cipher operations, breaking autofill functionality silently.


Finding 2: Inconsistent availability checks

⚠️ Minor Issue

The getCredentialIdentities(from:) method at AutofillCredentialService.swift:518 is marked @available(iOS 17.0, *), but it's called from methods that already guard on iOS 17 with runtime checks.

Issue: The calling methods use runtime availability checks (#available), while the helper uses compile-time availability (@available). This creates confusion about the availability semantics.

Recommendation: Either remove @available from getCredentialIdentities since callers already guard, or move the availability check into getCredentialIdentities and simplify callers. The current mix works but is inconsistent.


Finding 3: Decryption failure silently ignored

⚠️ Minor Issue - See inline comment

In CipherChangePublisher.swift:145-146, cipher decryption failures are silently ignored via try?.

Problem: If Cipher(cipherData:) throws (corrupted data, decryption issue), the change event is silently dropped without logging.

Impact: Credential store can become out of sync with actual vault state if cipher data corruption occurs.


Finding 4: Test coverage gaps

⚠️ Minor Issue

While overall coverage is good (94.9%), Codecov identifies 25 uncovered lines. Key gaps:

  1. CipherDataStoreTests.swift: 10 missing lines - appears to be test setup/teardown or edge cases
  2. AutofillCredentialService.swift: 8 missing lines - likely error paths in the new methods
  3. CipherServiceTests.swift: 3 missing lines - probably the new publisher method

Recommendation: Add tests for error handling paths in getCredentialIdentities, Fido2 identity extraction failures, and cipher decryption failures in upsertCredentialsInStore/removeCredentialsInStore.


Finding 5: Memory consideration for Fido2 credentials

💭 Question - See inline comment

In getCredentialIdentities(from:) at AutofillCredentialService.swift:525-532, does credentialsForAutofill() load all Fido2 credentials into memory before filtering by cipher ID?

Question: If so, this defeats the memory efficiency goal stated in the PR description. Verify if the SDK provides a method to fetch Fido2 credentials for a specific cipher ID, or if credentialsForAutofill() is lazy/efficient.


Finding 6: Task cancellation race condition

💭 Observation

In AutofillCredentialService.swift:220-222, the deinit cancels the subscription task. If the task is in the middle of processing a cipher change (decrypting, calling identity store), the cancellation might leave the identity store in an inconsistent state.

Question: Should there be graceful cleanup? Most likely the Task cancellation is sufficient since async operations check for cancellation, but worth considering.


Good Practices Observed

  • Clean separation of concerns with CipherChangePublisher as a reusable Combine publisher
  • iOS 17+ incremental updates properly guarded with supportsIncrementalUpdates check
  • Comprehensive test coverage for happy paths (insert/update/delete)
  • Proper use of [weak self] in async task to prevent retain cycles
  • Clear documentation explaining the iOS 17+ limitation and memory rationale

Action Items

Must Fix (Before Merge):

  • Finding 1: Add retry logic or fail-safe for cipherChangesPublisher() initialization errors

Should Fix (Before or Shortly After Merge):

  • Finding 2: Standardize availability check pattern
  • Finding 3: Add logging for cipher decryption failures in publisher
  • Finding 4: Add tests for error paths to increase coverage

Consider:

  • Finding 5: Verify Fido2 memory efficiency with SDK team
  • Finding 6: Document task cancellation behavior (if current approach is correct)

@github-actions
Copy link
Contributor

github-actions bot commented Nov 28, 2025

Logo
Checkmarx One – Scan Summary & Details0c2aa0e8-d34f-4aff-b706-e9b3fad6a3ed

Great job! No new security vulnerabilities introduced in this pull request

@codecov
Copy link

codecov bot commented Nov 28, 2025

Codecov Report

❌ Patch coverage is 94.90835% with 25 lines in your changes missing coverage. Please review.
✅ Project coverage is 85.42%. Comparing base (b734949) to head (a7e0017).

Files with missing lines Patch % Lines
...e/Vault/Services/Stores/CipherDataStoreTests.swift 89.36% 10 Missing ⚠️
.../Autofill/Services/AutofillCredentialService.swift 90.90% 8 Missing ⚠️
...hared/Core/Vault/Services/CipherServiceTests.swift 87.50% 3 Missing ⚠️
.../AutofillCredentialService+AppExtensionTests.swift 98.83% 2 Missing ⚠️
...ore/Platform/Utilities/CipherChangePublisher.swift 98.52% 1 Missing ⚠️
...vices/Stores/TestHelpers/MockCipherDataStore.swift 90.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #2169      +/-   ##
==========================================
+ Coverage   85.39%   85.42%   +0.03%     
==========================================
  Files        1731     1732       +1     
  Lines      145718   146209     +491     
==========================================
+ Hits       124434   124899     +465     
- Misses      21284    21310      +26     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Comment on lines +228 to +234
private func subscribeToCipherChanges() {
cipherChangesSubscriptionTask?.cancel()
cipherChangesSubscriptionTask = Task { [weak self] in
guard let self else { return }

do {
for await cipherChange in try await cipherService.cipherChangesPublisher().values {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Critical: If cipherChangesPublisher() throws during initialization, the error is logged but the subscription task completes silently. This means cipher changes won't update the credential store, breaking autofill functionality without any visible indication.

Recommendation: Add retry logic or fail-safe handling:

private func subscribeToCipherChanges() {
    cipherChangesSubscriptionTask?.cancel()
    cipherChangesSubscriptionTask = Task { [weak self] in
        guard let self else { return }
        
        var retryCount = 0
        let maxRetries = 3
        
        while retryCount < maxRetries && !Task.isCancelled {
            do {
                let publisher = try await cipherService.cipherChangesPublisher()
                for await cipherChange in publisher.values {
                    switch cipherChange {
                    case let .inserted(cipher):
                        await upsertCredentialsInStore(for: cipher)
                    case let .updated(cipher):
                        await upsertCredentialsInStore(for: cipher)
                    case let .deleted(cipher):
                        await removeCredentialsInStore(for: cipher)
                    }
                }
                break // Success
            } catch {
                errorReporter.log(error: error)
                retryCount += 1
                if retryCount < maxRetries {
                    try? await Task.sleep(nanoseconds: 1_000_000_000)
                }
            }
        }
    }
}

Comment on lines +525 to +530
let fido2Identities = try await clientService.platform().fido2()
.authenticator(
userInterface: fido2UserInterfaceHelper,
credentialStore: fido2CredentialStore,
)
.credentialsForAutofill()
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💭 Memory Concern: Does credentialsForAutofill() load all Fido2 credentials into memory before the .filter operation? If so, this defeats the memory efficiency goal of this PR for large vaults.

Question: Can we fetch Fido2 credentials for a specific cipher ID directly from the SDK, or is credentialsForAutofill() already efficient/lazy?

Comment on lines +143 to +145
guard let cipherData = object as? CipherData,
cipherData.userId == userId,
let cipher = try? Cipher(cipherData: cipherData) else { continue }
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Minor: Cipher decryption failures are silently ignored here. If Cipher(cipherData:) throws (corrupted data), the change event is dropped without logging, potentially causing credential store to become out of sync.

Recommendation: Log failures:

guard let cipherData = object as? CipherData,
      cipherData.userId == userId else { continue }

guard let cipher = try? Cipher(cipherData: cipherData) else {
    Logger.application.error("Failed to decode cipher \(cipherData.id ?? "unknown") for change notification")
    continue
}
_ = subscriber.receive(.inserted(cipher))

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants